/*
 * Copyright 2009-2010 Nanjing RedOrange ltd (http://www.red-orange.cn)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package redora.client.mvp;

import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.http.client.*;
import com.google.gwt.json.client.JSONArray;
import com.google.gwt.json.client.JSONObject;
import redora.client.Persistable;
import redora.client.Persistable.Scope;
import redora.client.Service;
import redora.client.constants.RedoraMessages;
import redora.client.util.ClientUtil;
import redora.client.util.QueryBuilder;
import redora.client.validation.BusinessRuleViolation;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.logging.Logger;

import static com.google.gwt.http.client.RequestBuilder.GET;
import static com.google.gwt.http.client.RequestBuilder.POST;
import static com.google.gwt.http.client.Response.SC_OK;
import static com.google.gwt.json.client.JSONParser.parseLenient;
import static java.util.logging.Level.*;
import static redora.client.util.GWTViewUtil.displayError;
import static redora.client.util.GWTViewUtil.displayInfo;

/**
 * Does most of the heavy lifting of the service object. Redora will
 * generate a PojoServiceBase (and PojoService stub) for you to implement
 * the pojo specific activities.<br>
 * There are two caches. The pojo cache {@link #pojoMap} will keep each fetch pojo.
 * The {@link #useCache(Long, redora.client.Persistable.Scope)} and {@link Service#cache(Long)}
 * handle the cache directly, however, fetching pojos is mostly done by invoking {@link #findById(Long, redora.client.Persistable.Scope)}.<br>
 * The second cache is the finder cache {@link #listMap}. All the finder queries are stored here based on their
 * url as key. Whenever a new pojo is persisted, the finder cache is cleared.
 *
 * @param <T>
 * @author Nanjing RedOrange (www.red-orange.cn)
 */
public abstract class ServiceBase<T extends Persistable> implements Service<T> {
    static Logger l = Logger.getLogger("ServiceBase");

    protected static RedoraMessages redoraMessages = GWT.create(RedoraMessages.class);

    /**
     * 50, if a FindById has more id's, the FindByID will will break the requests up in pieces of this size.
     */
    final static int FIND_BATCH_SIZE = 50;
    /**
     * Pojo cache.
     */
    protected final HashMap<Long, T> pojoMap = new HashMap<Long, T>();
    /**
     * Finder cache.
     */
    protected final HashMap<String, Long[]> listMap = new HashMap<String, Long[]>();
    /**
     * A FindById is never immediately executed, it is queued first in this queue.
     */
    protected final HashMap<Long, Scope> idQueue = new HashMap<Long, Scope>();

    protected final String baseFindUrl;
    protected final String basePersistUrl;
    protected final String baseDeleteUrl;
    protected final String baseTrashUrl;
    protected int finderStepSize = 50;

    public final static String FINDER_PARAM = "_df=";
    public final static String PARAM_PARAM = "&_p=";

    protected final ClientFactory clientFactory;
    protected final Class<T> cls;
    protected final ClientUtil<T> util;
    public final String NAME;

    protected ServiceBase(ClientFactory clientFactory, Class<T> cls) {
        this.clientFactory = clientFactory;
        this.cls = cls;
        NAME = cls.getName().substring(cls.getName().lastIndexOf(".") + 1).toLowerCase();
        this.baseFindUrl = "../rdo/find/" + NAME + "?";
        this.basePersistUrl = "../rdo/persist/" + NAME + "?";
        this.baseDeleteUrl = "../rdo/delete/" + NAME + "?";
        this.baseTrashUrl = "../rdo/trash/" + NAME + "?";
        util = clientFactory.getLocator().locateUtil(cls);
        l.log(INFO, "Initialized service for " + NAME);
    }

    /**
     * Default is 50, if a finder results in more records, the responding FindEvents are broken up in pieces of
     * this size.<br>
     * Make sure this always returns something larger then 0.
     */
    public int finderStepSize() {
        return finderStepSize;
    }

    public abstract String getAuthInfo();

    @Override
    public void findById(final Long id, final Scope scope) {
        if (useCache(id, scope)) {
            l.log(INFO, "Firing " + cls + ": " + id + " from cache.");
            clientFactory.getEventBus().fireEvent(new FetchEvent(cls, new Persistable[]{pojoMap.get(id)}));
        } else {
            addQueue(id, scope);
        }
    }

    @Override
    public void findById(final Long[] ids, final Scope scope) {
        ArrayList<Persistable> retCache = new ArrayList<Persistable>();
        for (Long id : ids) {
            if (useCache(id, scope)) {
                retCache.add(pojoMap.get(id));
            } else {
                addQueue(id, scope);
            }
        }
        if (!retCache.isEmpty()) {
            l.log(INFO, "Firing " + retCache.size() + " objects from cache for " + cls);
            clientFactory.getEventBus().fireEvent(new FetchEvent(cls, retCache.toArray(new Persistable[retCache.size()])));
        }
    }

    /**
     * Add's a 'find by id' to the queue, and schedules the queue to be executed.
     *
     * @param id
     * @param scope
     */
    protected void addQueue(Long id, Scope scope) {
        if (!idQueue.containsKey(id) || idQueue.get(id).compareTo(scope) > 0) {
            if (idQueue.isEmpty()) {
                Scheduler.get().scheduleDeferred(new Scheduler.ScheduledCommand() {
                    @Override
                    public void execute() {
                        executeFindById();
                    }
                });
            }
            idQueue.put(id, scope);
        }
    }

    /**
     * Flushes the idQueue by executing the requests. If the queue is
     * longer then {@link #FIND_BATCH_SIZE}, the queue will be split into
     * more parts.
     */
    protected void executeFindById() {
        final StringBuilder paramIds = new StringBuilder();
        String comma = "";
        int i = 0;
        for (Object id : idQueue.keySet().toArray()) {
            paramIds.append(comma)
                    .append(idQueue.get(id).ordinal())
                    .append(":")
                    .append(id);
            idQueue.remove(id);
            comma = ",";
            if (++i == FIND_BATCH_SIZE) {
                if (!idQueue.isEmpty()) {
                    Scheduler.get().scheduleDeferred(new Scheduler.ScheduledCommand() {
                        @Override
                        public void execute() {
                            executeFindById();
                        }
                    });
                }
                break;
            }
        }
        String _url = URL.encode(baseFindUrl + "_fids=" + paramIds);
        RequestBuilder builder = new RequestBuilder(GET, _url + getAuthInfo());
        builder.setCallback(new RequestCallback() {
            public void onError(Request request, Throwable e) {
                displayError(redoraMessages.responseErrorConnect());
                l.log(SEVERE, "Error message retrieved for findById: " + cls + ": " + paramIds, e);
            }

            public void onResponseReceived(Request request, Response response) {
                l.log(FINE, response.getText());
                if (response.getStatusCode() == SC_OK) {
                    JSONObject responseJSON = parseLenient(response.getText()).isObject().get("response").isObject();
                    int status = (int) responseJSON.get("status").isNumber().doubleValue();
                    if (status != 0)
                        l.log(INFO, "Response received: " + status);
                    if (customResponse(status, responseJSON)) {
                        JSONArray array = responseJSON.get("data").isArray();
                        l.log(INFO, "OK Response from server with " + array.size() + " for " + cls + ": " + paramIds);
                        ArrayList<T> ret = new ArrayList<T>();
                        for (int i = 0; i < array.size(); i++) {
                            T pojo = util.from(array.get(i).isObject());
                            pojoMap.put(pojo.getId(), pojo);
                            ret.add(pojo);
                        }
                        if (!ret.isEmpty()) {
                            clientFactory.getEventBus().fireEvent(new FetchEvent(cls, ret.toArray(new Persistable[ret.size()])));
                        }
                    }
                } else {
                    l.log(SEVERE, "FindById " + cls + ": " + paramIds + " retrieved undesirable return " + response.getStatusText());
                    displayError(redoraMessages.responseErrorNot200());
                }
            }
        });
        try {
            builder.send();
        } catch (RequestException e) {
            l.log(SEVERE, "Failed to retrieve " + cls + " with id " + paramIds, e);
            displayError(redoraMessages.responseErrorConnect());
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String url(int defaultFinder) {
        return URL.encode(baseFindUrl + "_df=" + defaultFinder);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String url(int defaultFinder, Long param) {
        return URL.encode(baseFindUrl + FINDER_PARAM + defaultFinder + PARAM_PARAM + param.toString());
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String url(int defaultFinder, String[] params) {
        String paramVal = "";
        String comma = "";
        for (String param : params) {
            paramVal += comma + param;
            comma = ",";
        }
        return URL.encode(baseFindUrl + "_df=" + defaultFinder + (paramVal.equals("") ? "" : PARAM_PARAM + paramVal));
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void finder(final String url, final Scope scope) {
        if (listMap.containsKey(url)) {
            l.log(INFO, "Retrieving list with " + listMap.get(url).length + " records from cache for finder " + url);
            Scheduler.get().scheduleIncremental(new Scheduler.RepeatingCommand() {
                private int last = 0;
                private int step = 0;

                @Override
                public boolean execute() {
                    Long[] ids = listMap.get(url);
                    ArrayList<Long> results = new ArrayList<Long>();
                    while (last < ids.length) {
                        if (ids[last] != null) {
                            results.add(ids[last++]);
                        } //else: the server scheduled finer has not responded yet, so don't do anything here
                        if (results.size() == finderStepSize()) {
                            l.log(INFO, "Firing " + finderStepSize() + " at step: " + step + " from cache for " + url + ", I expect more to come.");
                            break;
                        }
                    }
                    boolean hasMore = last < ids.length;
                    if (!results.isEmpty() || ids.length == 0) {
                        clientFactory.getEventBus().fireEvent(new FindEvent(cls, url, scope, results.toArray(new Long[results.size()]), step++, hasMore));
                    }
                    return hasMore;
                }
            });
        } else {
            final String urlWithScope = url + "&fetchScope=" + scope;
            l.log(INFO, "Retrieving list from server for finder (including scope) " + urlWithScope);

            RequestBuilder builder = new RequestBuilder(GET, urlWithScope + getAuthInfo());
            builder.setCallback(new RequestCallback() {
                public void onError(Request request, Throwable e) {
                    l.log(SEVERE, "Request failed " + url, e);
                    displayError(redoraMessages.responseErrorConnect());
                }

                public void onResponseReceived(Request request, Response response) {
                    l.log(FINE, response.getText());
                    l.log(FINE, "Finder Step Size:" + finderStepSize());
                    if (response.getStatusCode() == SC_OK) {
                        JSONObject responseJSON = parseLenient(response.getText()).isObject().get("response").isObject();
                        int status = (int) responseJSON.get("status").isNumber().doubleValue();
                        if (status != 0)
                            l.log(INFO, "Response received: " + status);
                        if (customResponse(status, responseJSON)) {
                            final JSONArray array = responseJSON.get("data").isArray();
                            final int steps = (array.size() == 0 ? 1 : array.size()) / finderStepSize() + (array.size() % finderStepSize() == 0 ? 0 : 1);
                            l.log(INFO, "OK Response from server with " + array.size() + " records from " + url + ". I will fire the results in " + steps + " events.");
                            listMap.put(url, new Long[array.size()]);

                            Scheduler.get().scheduleIncremental(new Scheduler.RepeatingCommand() {
                                private int step = 0;

                                @Override
                                public boolean execute() {
                                    int stepSize = array.size() - step * finderStepSize() > finderStepSize() ? finderStepSize() : array.size() - step * finderStepSize();
                                    FindEvent event = new FindEvent(cls, url, scope, new Long[stepSize], step, false);
                                    for (int j = 0; j < stepSize; j++) {
                                        int p = step * finderStepSize() + j;
                                        T pojo = util.from(array.get(p).isObject());
                                        //if (!useCache(pojo.getId(), scope)) {
                                            pojoMap.put(pojo.getId(), pojo);
                                        //}
                                        listMap.get(url)[p] = pojo.getId();
                                        event.results[j] = pojo.getId();
                                    }
                                    l.log(INFO, "Firing FindEvent " + step + " out of " + steps + " for " + url);
                                    event.hasMore = ++step < steps;
                                    clientFactory.getEventBus().fireEvent(event);
                                    return event.hasMore;
                                }
                            });
                        }
                    } else {
                        l.log(SEVERE, "Request failed, status " + response.getStatusCode() + ", url: " + url);
                        displayError(redoraMessages.responseErrorNot200());
                    }
                }
            });
            try {
                builder.send();
            } catch (RequestException e) {
                l.log(SEVERE, "Failed to get Skin list from server", e);
                displayError(redoraMessages.responseErrorConnect());
            }
        }
    }


    /**
     * {@inheritDoc}
     * Persists given pojo. When successful a FetchEvent is fired with the persisted result.
     * When a business rule was violated, a BusinessRuleViolationEvent is fired.
     * For other errors a PersistEvent is fired.
     *
     * @param pojo (Mandatory) New or changed pojo.
     */
    @Override
    public void persist(final T pojo) {
        String _url = URL.encode(basePersistUrl);
        final QueryBuilder queryBuilder = new QueryBuilder();
        queryBuilder.addQuery("_operationType", "persist");
        final boolean wasNew;

        if (pojo.getId() != null) {
            queryBuilder.addQuery("id", pojo.getId().toString());
            wasNew = false;
        } else {
            wasNew = true;
        }

        buildRequest(queryBuilder, pojo);

        l.log(INFO, "Ready to sent persist request: " + queryBuilder.toString());
        RequestBuilder builder = new RequestBuilder(POST, _url);
        builder.setHeader("Content-Type", "application/x-www-form-urlencoded");
        String requestData = queryBuilder.toString();
        builder.setRequestData(requestData + getAuthInfo());
        builder.setCallback(new RequestCallback() {
            public void onError(Request request, Throwable e) {
                l.log(SEVERE, "Failed to submit persist: " + queryBuilder.toString(), e);
                displayError(redoraMessages.responseErrorConnect());
            }

            public void onResponseReceived(Request request, Response response) {
                l.log(FINE, response.getText());
                if (response.getStatusCode() == SC_OK) {
                    JSONObject responseJSON = parseLenient(response.getText()).isObject().get("response").isObject();
                    int status = (int) responseJSON.get("status").isNumber().doubleValue();
                    l.log(INFO, "Response received " + status);
                    if (customResponse(status, responseJSON)) {
                        if (ResponseStatus.values()[(int) responseJSON.get("status").isNumber().doubleValue()] == ResponseStatus.success) {
                            Persistable success = util.from(responseJSON.get("data").isObject());
                            //if (wasNew) {
                            l.log(INFO, "Clearing finder cache");
                            listMap.clear();
                            pojoMap.put(success.getId(), (T) success);
                            //}
                            clientFactory.getEventBus().fireEvent(new PersistEvent(cls, success, wasNew));
                            l.log(INFO, "Persist successful, fired PersistEvent for " + success);
                            displayInfo(redoraMessages.recordSaved());
                        } else {
                            //TODO How to handle the loginExpired response... LoginEvent?
                            JSONArray array = responseJSON.get("violations").isArray();
                            BusinessRuleViolation[] violations = new BusinessRuleViolation[array.size()];
                            for (int i = 0; i < array.size(); i++) {
                                violations[i] = BusinessRuleViolation.from(array.get(i).isObject(), clientFactory);
                                l.log(INFO, "Added violation " + violations[i]);
                            }
                            clientFactory.getEventBus().fireEvent(new PersistEvent(cls, pojo, violations, wasNew));
                            l.log(INFO, "Fired PersistEvent with " + violations.length + " business rule violation(s).");
                        }
                    }
                } else {
                    l.log(SEVERE, "Retrieved unwanted response " + response.getStatusText() + " at persisting " + pojo);
                    displayError(redoraMessages.responseErrorNot200());
                }
            }
        });
        try {
            builder.send();
        } catch (RequestException e) {
            l.log(SEVERE, "Failed to persist " + pojo, e);
            displayError(redoraMessages.responseErrorConnect());
        }
    }

    /**
     * {@inheritDoc}
     * Delete given pojo. When successful a DeleteEvent is fired with the persisted result.
     *
     * @param pojo (Mandatory) pojo for deleting.
     */
    @Override
    public void delete(final T pojo) {
        String _url = URL.encode(baseDeleteUrl);
        final QueryBuilder queryBuilder = new QueryBuilder();
        queryBuilder.addQuery("_operationType", "delete");
        queryBuilder.addQuery("id", pojo.getId());

        l.log(INFO, "Ready to sent delete request: " + queryBuilder.toString());
        RequestBuilder builder = new RequestBuilder(POST, _url);
        builder.setHeader("Content-Type", "application/x-www-form-urlencoded");
        String requestData = queryBuilder.toString();
        builder.setRequestData(requestData + getAuthInfo());
        builder.setCallback(new RequestCallback() {
            public void onError(Request request, Throwable e) {
                l.log(SEVERE, "Failed to submit delete: " + queryBuilder.toString(), e);
                displayError(redoraMessages.responseErrorConnect());
            }

            public void onResponseReceived(Request request, Response response) {
                if (response.getStatusCode() == SC_OK) {
                    JSONObject responseJSON = parseLenient(response.getText()).isObject().get("response").isObject();
                    int status = (int) responseJSON.get("status").isNumber().doubleValue();
                    l.log(INFO, "Response received " + status);
                    l.log(FINE, responseJSON.toString());
                    if (customResponse(status, responseJSON)) {
                        if (ResponseStatus.values()[status] == ResponseStatus.success) {
                            l.log(INFO, "Clearing finder cache");
                            listMap.clear();
                            clientFactory.getEventBus().fireEvent(new DeleteEvent(cls, pojo));
                            l.log(INFO, "Delete successful, fired DeleteEvent for " + pojo);
                            displayInfo(redoraMessages.recordDeleted());
                        } else {
                            JSONArray array = responseJSON.get("violations").isArray();
                            BusinessRuleViolation[] violations = new BusinessRuleViolation[array.size()];
                            for (int i = 0; i < array.size(); i++) {
                                violations[i] = BusinessRuleViolation.from(array.get(i).isObject(), clientFactory);
                                l.log(INFO, "Added violation " + violations[i]);
                            }
                            clientFactory.getEventBus().fireEvent(new DeleteEvent(cls, pojo, violations));
                            l.log(INFO, "Fired DeleteEvent with " + violations.length + " business rule violation(s).");
                        }
                    } else {
                        l.log(SEVERE, "Retrieved unwanted response " + response.getStatusText() + " at deleting " + pojo);
                        displayError(redoraMessages.responseErrorNot200());
                    }
                }
            }
        });
        try {
            builder.send();
        } catch (RequestException e) {
            l.log(SEVERE, "Failed to delete", e);
            displayError(redoraMessages.responseErrorConnect());
        }
    }

    public abstract boolean customResponse(int status, JSONObject responseJSON);

    protected abstract void buildRequest(QueryBuilder builder, T pojo);

    /**
     * {@inheritDoc}
     *
     * @return True if the cache contains a pojo with given Id that has at least requested scope.
     */
    @Override
    public boolean useCache(Long id, Scope scope) {
        return (pojoMap.containsKey(id) && pojoMap.get(id).scope().compareTo(scope) <= 0);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Long[] cache(String url) {
        return listMap.get(url);
    }

    /**
     * {@inheritDoc}
     *
     * @return True if the finder cache contain the finder url
     */
    @Override
    public boolean useCache(String url) {
        return listMap.containsKey(url);
    }

    /**
     * Clear the finder cache .
     */
    public void clearFinderCache() {
        listMap.clear();
    }

    /**
     * Clear both the finder cache and pojo cache.
     */
    public void clearCache() {
        listMap.clear();
        pojoMap.clear();
    }

    /**
     * Clear the finder cache with the url.
     */
    public void clearCache(String url) {
        if (listMap.containsKey(url))
            listMap.remove(url);
    }

    /**
     * Add pojo to pojo cache.
     */
    public void addPojo(T pojo) {
        pojoMap.put(pojo.getId(), pojo);
    }

    /**
     * Add results to listMap cache
     */
    public void addResult(String url, Long[] result) {
        listMap.put(url, result);
    }
}